Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Scalar mapping and adapter configuration improvements #3779

Merged
merged 24 commits into from
Jan 25, 2022

Conversation

BoD
Copy link
Contributor

@BoD BoD commented Jan 10, 2022

Related to #3748.

For now here is a design document with possible changes.

@BoD BoD force-pushed the scalar-adapters-in-gradle-plugin-conf branch from 9796670 to ce8bed2 Compare January 12, 2022 08:54
Comment on lines 98 to 107
sealed interface AdapterInitializer

// The adapter will be instantiated in the generated code
class SingletonAdapterInitializer(val qualifiedName: String): AdapterInitializer

// The adapter will be used as-is (it's an object or a public val)
class NoArgConstructorAdapterInitializer(val qualifiedName: String): AdapterInitializer

// The adapter will be looked up in the `customScalarAdapters` parameter (same as current behavior)
object RuntimeAdapterInitializer: AdapterInitializer
Copy link
Contributor

@martinbonnin martinbonnin Jan 13, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know I'm the one who suggested this initially but I think we should avoid Kotlin APIs (like sealed classes) in the Gradle plugin API.

Mainly for Groovy users + because we use R8 to relocate all of the Kotlin-stdlib, this feels dangerous. I'd suggest something like this instead:

class Service {
  fun mapScalar(graphQLName: String, kotlinName: String)
  fun mapScalarUsingKotlinSingleton(graphQLName: String, kotlinName: String, singletonName: String)
  fun mapScalarUsingKotlinConstructor(graphQLName: String, kotlinName: String, constructorName: String)

  fun mapScalarToKotlinLong(graphQLName: String)
  fun mapScalarToKotlinInt(graphQLName: String)
  fun mapScalarToKotlinString(graphQLName: String)
  fun mapScalarToKotlinBoolean(graphQLName: String)
  fun mapScalarToKotlinMap(graphQLName: String)

  fun mapScalarToJavaLong(graphQLName: String)
  fun mapScalarToJavaInteger(graphQLName: String)
  fun mapScalarToJavaString(graphQLName: String)
  fun mapScalarToJavaBoolean(graphQLName: String)
  fun mapScalarToJavaMap(graphQLName: String)
}

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed!
Plus I actually find the syntax easier to use 👍

Shouldn't mapScalarUsingKotlinSingleton / mapScalarUsingKotlinConstructor be mapScalarUsingSingleton / mapScalarUsingConstructor?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't mapScalarUsingKotlinSingleton / mapScalarUsingKotlinConstructor be mapScalarUsingSingleton / mapScalarUsingConstructor?

I was wondering the same. Singleton is a Kotlin concept so it will not work with Java but maybe we can throw a nice error if that happens?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was assuming this would work also in Java with static fields:

public class MyAdapters {
  public static final Adapter<MyDate> DATE_ADAPTER = new Adapter<MyDate>() {...}
}
mapScalarUsingSingleton("Date", "com.example.MyDate", "com.example.MyAdapters.DATE_ADAPTER")

But now I'm realizing maybe the name "singleton" is not the most accurate?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can "just" ask for a verbatim code block?

apollo {
  mapScalarUsingAdapter("Date", "com.example.MyDate", "com.example.MyAdapters.DATE_ADAPTER")
  mapScalarUsingAdapter("Date", "com.example.MyDate", "com.example.MyAdapterSingleton")
  mapScalarUsingAdapter("Date", "com.example.MyDate", "com.example.MyAdapter()")
}

And use a literal (%L) codeblock in KotlinPoet/JavaPoet. This will not add the nice imports but maybe that's ok?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I agree, the distinction is not that useful. The documentation can explain how it works, and the fact that an instance or an instantiation expression can both be used.

In that case, maybe we can keep the name mapScalar?

  /**
   * Map a GraphQL scalar type to the Java/Kotlin type and adapter configured at runtime.
   * 
   * For example:
   * `mapScalar("Date", "com.example.Date")`
   */
  fun mapScalar(graphQLName: String, targetName: String)

  /**
   * Map a GraphQL scalar type to the Java/Kotlin type and provided adapter expression.
   *
   * For example:
   * `mapScalar("Date", "com.example.Date", "com.example.DateAdapter")` (an instance property or object)
   * `mapScalar("Date", "com.example.Date", "com.example.DateAdapter()")` (instantiate the class on the fly)
   */
  fun mapScalar(graphQLName: String, targetName: String, adatpterExpression: String)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mapScalarToKotlinMap / mapScalarToJavaMap should be mapScalarToKotlinAny / mapScalarToJavaObject or am I missing something?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, indeed. Until we have #3243, we can't use Map or other parametrized classes.

@BoD
Copy link
Contributor Author

BoD commented Jan 14, 2022

I've pushed the Kotlin implementation for review

Remaining:

  • Java
  • Documentation updates

@@ -170,6 +170,11 @@ object ApolloCompiler {
options.operationOutputFile.writeText(operationOutput.toJson(" "))
}

// Merge scalar mappings from upstream modules and this module's options
val allScalarMapping = options.incomingCompilerMetadata.fold(options.scalarMapping.toMutableMap()) { acc, it ->
acc.apply { putAll(it.scalarMapping) }
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This feels dangerous. What if there's a conflicting mapping in 2 modules? Should we mandate that all scalar mappings are defined in the schema module?

I can't think of any use case where different modules could use different mappings but if something comes up we can always relax the restriction.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought it was already the case actually. The multi module doc says "The schema module and only the schema module must define customScalarsMapping".

Should I ensure there are no duplicates and throw if there are any? Or should we not merge them at all (not sure how that would work)?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Better throw fast if we see something like this.

* Controls how scalar adapters are used in the generated code.
*/
@JsonClass(generateAdapter = true, generator = "sealed:type")
sealed interface AdapterInitializer : Serializable
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why does it need to be Serializable ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Got it. This is need by Gradle to snapshot the input properties.

I always feel weird implementing Serializable since it's already serialized to Json. Also using Kotlin features like sealed interfaces and data classes in Gradle APIs feels dangerous too (think Groovy callers).

I would certainly have used 2 maps:

scalarsMapping: Map<String, String>
scalarAdapters: Map<String, String>

But I can't find a strong reason for the change so your choice!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes sorry I should have commented :). I agree, I'm not very fond of making them as Serializable either. Using String feels safer, I'll make the change.

@@ -132,8 +133,8 @@ internal class IrBuilder(
visitedTypes.add(name)
val typeDefinition = schema.typeDefinition(name)
if (typeDefinition.isBuiltIn()) {
// We don't generate builtin types
continue
// We don't generate builtin types, unless they are explicitly mapped
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// We don't generate builtin types, unless they are explicitly mapped
// We don't generate builtin types, unless they are explicitly mapped
// Because users need String.type (or others) to register their adapters

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thinking a bit more about this. I think it makes sense to keep the builtin types in apollo-api always and not generate them. This means for builtin types that require a runtime adapter, users need to do something like:

apolloClientBuilder.addScalarAdapter(CompiledIDType, MyIDAdapter)
// instead of
// apolloClientBuilder.addScalarAdapter(ID.type, MyIDAdapter)

But I think this is such an advanced case that it's ok? It keeps the logic more symmetric. Builting types are in apollo-api, others get generated

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 I'll have a look!

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In retrospect, CompiledIDType should certainly have been BuiltinIDType but that'll be for v4 😅

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm after more digging, the CustomScalarAdapters methods take instances of CustomScalarTypes, whereas the compiled types are ScalarType - so apolloClientBuilder.addScalarAdapter(ID.type, MyIDAdapter) for instance doesn't work.

We could make CustomScalarType be a subclass of ScalarType, and change CustomScalarAdapters methods to take ScalarType instead. But that looks like a breaking change 😅

Or we can add ScalarType based methods (responseAdapterFor, add, addCustomScalarAdapter) in addition to the existing CustomScalarType based ones.

WDYT?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gosh this is hard. I'm not too much a fan of duplicating everything either. I think your initial approach of generating a CustomScalarType for builtin types if they have a mapping was best then. CustomScalarType will not be a super accurate name and the whole thing feels a bit asymmetrical but I don't see a better way.

Or should we always generate all builtin types? And mark CompiledStringType, etc.. as deprecated? I was initially not too fond of this because it puts String, Int, etc.. as top level classes but maybe since they're using a specific package name, that'll be ok...

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Or should we always generate all builtin types?

I'll five it a go so we can see what it looks like :)

service.customTypeMapping
)
.getOrNull()?.asScalarInfoMapping() ?: emptyMap()
)
Copy link
Contributor

@martinbonnin martinbonnin Jan 16, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

throw if both customScalarsMapping and scalarMapping are set?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point!

type is IrModelType -> resolveAndAssert(ResolverKeyKind.Model, type.path)
type is IrNamedType -> resolveAndAssert(ResolverKeyKind.SchemaType, type.name)
else -> error("$type is not a schema type")
}.copy(nullable = true)
}

private fun resolveIrScalarType(type: IrScalarType): ClassName {
// Try mapping first, then built-ins, then fallback to Any
return resolve(ResolverKeyKind.CustomScalarTarget, type.name) ?: when (type.name) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rename CustomScalarTarget to ScalarTarget?

"ID" -> CodeBlock.of("%M", KotlinSymbols.StringAdapter)
"String" -> CodeBlock.of("%M", KotlinSymbols.StringAdapter)
"Int" -> CodeBlock.of("%M", KotlinSymbols.IntAdapter)
"Float" -> CodeBlock.of("%M", KotlinSymbols.DoubleAdapter)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd love this to be moved up a few layers so that the resolver can apply the same logic everywhere. Not really sure how to do this yet but I might investigate this in a follow up PR unless you have ideas how to do this already.

@BoD BoD marked this pull request as ready for review January 24, 2022 08:44
@BoD BoD requested review from sav007 and tasomaniac as code owners January 24, 2022 08:44
BoD and others added 16 commits January 25, 2022 20:38
… code (#3793)

* Mark compiled types as deprecated and no longer use them in generated code

* Always generate types for built-in scalars

* Fix MetadataTest

* Prefix builtin scalar names with 'GraphQL' to avoid a clash in multiplatform

* Schema object: revert using "__Schema" name by default and add a `generatedSchemaName` option.
Revert to using compiled types for non scalar built-in types (__Schema, __Field, etc.).
…3795)

* Apply same changes to Java codegen

* Update Upload's kdoc about how to use it in the plugin conf

* Java: mark compiled types as deprecated and no longer use them in generated code

* Rebase on feature branch and apply last changes on KotlinResolver to JavaResolver
* rename custom-scalar test to scalar-adapters

* fix KDoc for registerOperations

* add a section in CONTRIBUTING.md about naming Gradle APIs

* Kdoc tweaks

* add a test to make sure we don't allow mapScalar + customScalarMapping
at the same time

* remove ScalarTarget as a resolver entry and instead use scalarMapping

* fix Gradle tests
@BoD BoD force-pushed the scalar-adapters-in-gradle-plugin-conf branch from 7094dbc to 4c82ca9 Compare January 25, 2022 19:39
@BoD BoD merged commit a1536a2 into main Jan 25, 2022
@BoD BoD deleted the scalar-adapters-in-gradle-plugin-conf branch January 25, 2022 20:16
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants